Skip to content

(deepgram stt): detect a silently dropped socket instead of hanging - #7206

Merged
tinalenguyen merged 1 commit into
livekit:mainfrom
GregHolmes:fix/deepgram-v1-stt-socket-liveness
Sep 11, 2026
Merged

tinalenguyen merged 1 commit into
livekit:mainfrom
GregHolmes:fix/deepgram-v1-stt-socket-liveness

Conversation

@GregHolmes

Copy link
Copy Markdown
Contributor

On a half-open connection (no FIN, no RST) the v1 Nova-3 SpeechStream can sit on a dead socket indefinitely: the session stays open, no transcripts arrive, and no error is ever emitted.

Three gaps

1. No heartbeat. stt.py::_connect_ws opens the socket without heartbeat, and aiohttp defaults both heartbeat and receive_timeout to None, so recv_task parks on ws.receive() forever. The reconnect loop in _run is exception-driven, as is the retry in RecognizeStream._main_task, so with nothing raising neither one runs. stt_v2 already passes heartbeat=30.0; v1 was the only Deepgram stream left unbounded.

2. The keepalive swallowed drops. keepalive_task caught every exception, logged a warning and returned. When no audio is flowing that write is the only thing touching the socket, so it is where a drop surfaces first. Connection errors now re-raise as a retryable APIConnectionError, symmetric with what send_task and recv_task already do after #6429.

3. WSMsgType.ERROR was treated as an unexpected message type in recv_task, logged and stepped over. That is the shape a heartbeat timeout actually arrives in, so the loop only recovered because aiohttp reports CLOSED on the next pass, and the reason on ws.exception() was discarded in favour of a bare 1006. This one applies to stt_v2.py as well, which already had the heartbeat.

Verification

Run against api.deepgram.com through a proxy that holds both sockets open and stops delivering, so the client's writes keep succeeding and nothing ever comes back:

before after
sockets opened 1 2
errors emitted none recoverable
transcripts after the break 0 resume at +6s
outcome silent for the full 135s window detected in 46s, recovered

The error now carries its cause:

deepgram connection lost (caused by ServerTimeoutError: No PONG received after 15.0 seconds)

Three regression tests added, all of which fail on main.

One open question

Detection takes ~46s (30s ping interval plus a 15s pong timeout). That is parity with the heartbeat=30.0 stt_v2 already ships, so I kept it rather than changing both, but 46s of dead air is a long time on a live call. Happy to lower it if you would prefer.

🤖 Generated with Claude Code

On a half-open connection (no FIN, no RST) the v1 Nova-3 SpeechStream could
sit on a dead socket indefinitely: the session stayed open, no transcripts
arrived, and no error was ever emitted. Three gaps.

1. `stt.py::_connect_ws` opened the socket without `heartbeat`. aiohttp
   defaults both `heartbeat` and `receive_timeout` to None, so `recv_task`
   parks on `ws.receive()` forever. The reconnect loop in `_run` is
   exception-driven, as is the retry in `RecognizeStream._main_task`, so
   with nothing raising neither ever runs. stt_v2 already passes
   `heartbeat=30.0`; v1 was the only Deepgram stream left unbounded.

2. `stt.py::keepalive_task` caught every exception, logged a warning and
   returned. When no audio is flowing that write is the only thing touching
   the socket, so it is where a drop surfaces first, and swallowing it left
   the stream alive on a connection that was gone. Connection errors now
   re-raise as a retryable APIConnectionError, symmetric with what
   send_task and recv_task already do after livekit#6429.

3. `recv_task` in both stt.py and stt_v2.py treated WSMsgType.ERROR as an
   unexpected message type: it logged and continued. That is the shape a
   heartbeat timeout actually arrives in, so the loop only recovered
   because aiohttp reports CLOSED on the next iteration, and the reason on
   `ws.exception()` was discarded in favour of a bare 1006. This one
   applies to Flux too, which already had the heartbeat.

Verified end to end against api.deepgram.com through a proxy that holds
both sockets open and stops delivering. Before: one socket, no reconnect,
no error, silent for the full 135s window. After: detected in 46s and
transcribing again 6s later, reported as

  deepgram connection lost (caused by ServerTimeoutError:
  No PONG received after 15.0 seconds)

Three regression tests, all of which fail on main.
@GregHolmes
GregHolmes requested a review from a team as a code owner September 10, 2026 09:55
@CLAassistant

CLAassistant commented Sep 10, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Devin Review

Comment on lines +603 to +610
except (aiohttp.ClientError, ConnectionError) as e:
# when no audio is flowing this write is the only thing touching the
# socket, so it is where a drop surfaces first. if the close is
# expected just return; otherwise re-raise as a retryable APIError so
# _main_task reconnects, symmetric with send_task and recv_task.
if closing_ws or self._session.closed:
return
raise APIConnectionError("deepgram connection closed unexpectedly") from e

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Keepalive failures expose raw exception data

Keepalive failures preserve raw aiohttp exceptions in __cause__. Task and retry logs can expose URLs, credentials, or provider data from that cause.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked this against the types that can actually reach here, and I don't think it holds for this path, though the concern class is a real one in this repo (#6739, #7031).

send_str() on an established websocket can raise ClientConnectionResetError, ClientOSError or ConnectionResetError. I rendered each through APIError.__str__ and checked both str() and repr() for an API key and for the URL:

cause str() repr()
ClientConnectionResetError clean clean
ClientOSError clean clean
ConnectionResetError clean clean
ClientResponseError (for contrast) clean leaks the key

ClientResponseError is the one that carries credentials, because RequestInfo.repr prints the Authorization header. That is the #6739 leak. It is raised during the HTTP request/response cycle, not by a frame write on an already-upgraded socket, so it cannot arise here, and it is already guarded at the site where it can, in _connect_ws with from None.

Worth noting too that APIError.__str__ deliberately walks __cause__, on the reasoning that the root of the chain is the only place the real failure is named. Dropping the cause here would leave _main_task logging a bare "connection closed unexpectedly" with no indication of why.

Happy to add an explicit isinstance guard against ClientResponseError if you would prefer it belt-and-braces, but as written I believe it is unreachable.

Comment on lines +680 to +687
if msg.type == aiohttp.WSMsgType.ERROR:
if closing_ws or self._session.closed:
return

# the heartbeat closes the socket when a ping goes unanswered,
# and that surfaces here rather than as a close frame.
# ws.exception() is the only place the reason survives.
raise APIConnectionError("deepgram connection lost") from ws.exception()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Heartbeat failures expose raw exception data

WSMsgType.ERROR preserves ws.exception() in __cause__. Task and retry logs can expose URLs, credentials, or provider data in both streams.

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the keepalive comment above: I don't think this one reaches a credential-bearing exception either.

ws.exception() on a websocket that has already completed its 101 upgrade returns reader and protocol errors: ServerTimeoutError for the heartbeat case this branch exists to catch, plus ClientPayloadError and WebSocketError. None of those carry a URL or headers in str() or repr(). The type that does, ClientResponseError, belongs to the handshake and is already handled with from None in _connect_ws.

Preserving the cause is the whole point of this branch. Without it a heartbeat timeout surfaces as a bare 1006 "closed unexpectedly", which is what happens on main today; with it the session error reads:

deepgram connection lost (caused by ServerTimeoutError: No PONG received after 15.0 seconds)

@tinalenguyen tinalenguyen left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thank you for the PR!

@tinalenguyen
tinalenguyen merged commit bfd4691 into livekit:main Sep 11, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants